diff options
Diffstat (limited to 'app/[lng]/partners/data-room/[projectId]/stats/page.tsx')
| -rw-r--r-- | app/[lng]/partners/data-room/[projectId]/stats/page.tsx | 373 |
1 files changed, 373 insertions, 0 deletions
diff --git a/app/[lng]/partners/data-room/[projectId]/stats/page.tsx b/app/[lng]/partners/data-room/[projectId]/stats/page.tsx new file mode 100644 index 00000000..7f652a99 --- /dev/null +++ b/app/[lng]/partners/data-room/[projectId]/stats/page.tsx @@ -0,0 +1,373 @@ +// app/projects/[projectId]/stats/page.tsx +'use client'; + +import { use, useState, useEffect } from 'react'; +import { + BarChart3, + TrendingUp, + HardDrive, + Users, + Eye, + Download, + Upload, + Calendar, + FileText, + FolderOpen, + Activity +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; + +interface ProjectStats { + storage: { + used: number; + limit: number; + fileCount: number; + folderCount: number; + byCategory: { + public: number; + restricted: number; + confidential: number; + internal: number; + }; + }; + activity: { + views: number; + downloads: number; + uploads: number; + shares: number; + trend: number; // 증감률 + }; + users: { + total: number; + active: number; + byRole: { + admin: number; + editor: number; + viewer: number; + }; + }; + recent: { + type: string; + user: string; + action: string; + timestamp: string; + details: string; + }[]; +} + +export default function ProjectStatsPage({ + params +}: { + params: Promise<{ projectId: string }> +}) { + // Next.js 15에서 params를 unwrap + const resolvedParams = use(params); + const projectId = resolvedParams.projectId; + + const [stats, setStats] = useState<ProjectStats | null>(null); + const [loading, setLoading] = useState(true); + const [dateRange, setDateRange] = useState('30d'); + const { toast } = useToast(); + + useEffect(() => { + fetchStats(); + }, [projectId, dateRange]); + + const fetchStats = async () => { + try { + setLoading(true); + const response = await fetch( + `/api/projects/${projectId}/stats?range=${dateRange}` + ); + + if (!response.ok) { + if (response.status === 403) { + throw new Error('통계를 볼 권한이 없습니다'); + } + throw new Error('통계 로드 실패'); + } + + const data = await response.json(); + setStats(data); + } catch (error: any) { + toast({ + title: '오류', + description: error.message || '통계를 불러올 수 없습니다.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatNumber = (num: number) => { + return new Intl.NumberFormat('ko-KR').format(num); + }; + + if (loading) { + return ( + <div className="p-6"> + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + {[...Array(8)].map((_, i) => ( + <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" /> + ))} + </div> + </div> + ); + } + + if (!stats) { + return ( + <div className="p-6 text-center"> + <BarChart3 className="h-12 w-12 mx-auto mb-3 text-muted-foreground" /> + <p className="text-muted-foreground">통계를 불러올 수 없습니다</p> + </div> + ); + } + + const storagePercentage = (stats.storage.used / stats.storage.limit) * 100; + + return ( + <div className="p-6 space-y-6"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div> + <h1 className="text-2xl font-bold">프로젝트 통계</h1> + <p className="text-muted-foreground mt-1"> + 프로젝트 사용 현황과 활동 내역을 확인합니다 + </p> + </div> + + <Tabs value={dateRange} onValueChange={setDateRange}> + <TabsList> + <TabsTrigger value="7d">7일</TabsTrigger> + <TabsTrigger value="30d">30일</TabsTrigger> + <TabsTrigger value="90d">90일</TabsTrigger> + </TabsList> + </Tabs> + </div> + + {/* 주요 지표 */} + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">스토리지 사용량</CardTitle> + <HardDrive className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {formatBytes(stats.storage.used)} + </div> + {/* <Progress value={storagePercentage} className="mt-2" /> */} + {/* <p className="text-xs text-muted-foreground mt-1"> + 총 {formatBytes(stats.storage.limit)} 중 {storagePercentage.toFixed(1)}% 사용 + </p> */} + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">파일 수</CardTitle> + <FileText className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {formatNumber(stats.storage.fileCount)} + </div> + <p className="text-xs text-muted-foreground mt-1"> + 폴더 {formatNumber(stats.storage.folderCount)}개 포함 + </p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">활성 사용자</CardTitle> + <Users className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {stats.users.active} + </div> + <p className="text-xs text-muted-foreground mt-1"> + 전체 {stats.users.total}명 중 + </p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">총 다운로드</CardTitle> + <Download className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {formatNumber(stats.activity.downloads)} + </div> + <div className="flex items-center gap-1 mt-1"> + {stats.activity.trend > 0 ? ( + <TrendingUp className="h-3 w-3 text-green-500" /> + ) : ( + <TrendingUp className="h-3 w-3 text-red-500 rotate-180" /> + )} + <span className={cn( + "text-xs", + stats.activity.trend > 0 ? "text-green-500" : "text-red-500" + )}> + {Math.abs(stats.activity.trend)}% + </span> + </div> + </CardContent> + </Card> + </div> + + {/* 상세 통계 */} + <div className="grid gap-6 md:grid-cols-2"> + {/* 파일 카테고리 분포 */} + <Card> + <CardHeader> + <CardTitle>파일 카테고리</CardTitle> + <CardDescription>카테고리별 파일 분포</CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-green-500 rounded-full" /> + <span className="text-sm">Public</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.public} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-yellow-500 rounded-full" /> + <span className="text-sm">Restricted</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.restricted} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-red-500 rounded-full" /> + <span className="text-sm">Confidential</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.confidential} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-blue-500 rounded-full" /> + <span className="text-sm">Internal</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.internal} + </span> + </div> + </CardContent> + </Card> + + {/* 활동 요약 */} + <Card> + <CardHeader> + <CardTitle>활동 요약</CardTitle> + <CardDescription>기간별 활동 내역</CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Eye className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">조회수</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.views)} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Download className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">다운로드</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.downloads)} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Upload className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">업로드</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.uploads)} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Users className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">공유</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.shares)} + </span> + </div> + </CardContent> + </Card> + </div> + +{/* 최근 활동 */} +<Card> + <CardHeader> + <CardTitle>최근 활동</CardTitle> + <CardDescription>프로젝트 내 최근 활동 내역</CardDescription> + </CardHeader> + + {/* 패딩이 스크롤에 포함되도록 CardContent p-0 + 내부 래퍼에 패딩 */} + <CardContent className="p-0"> + <div + className="max-h-80 md:max-h-96 xl:max-h-[480px] overflow-y-auto px-6 pb-6" + style={{ scrollbarGutter: "stable" }} // 스크롤바 생겨도 레이아웃 흔들림 방지 + aria-label="최근 활동 스크롤 영역" + tabIndex={0} // 키보드 포커스 가능 + > + <ul role="list" className="divide-y"> + {stats.recent.map((activity, index) => ( + <li key={index} className="flex items-center gap-3 py-3"> + <Activity className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="min-w-0 flex-1"> + <p className="text-sm"> + <span className="font-medium">{activity.user}</span> + {" "}님이{" "} + <span className="font-medium">{activity.details}</span> + {activity.action === "upload" && "을(를) 업로드했습니다"} + {activity.action === "download" && "을(를) 다운로드했습니다"} + {activity.action === "view" && "을(를) 조회했습니다"} + {activity.action === "share" && "을(를) 공유했습니다"} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {new Date(activity.timestamp).toLocaleString()} + </p> + </div> + </li> + ))} + </ul> + </div> + </CardContent> +</Card> + </div> + ); +}
\ No newline at end of file |
